Repository navigation
Conversation
c37c661 to
71e0a48
Compare
The premise is flawed. We already have a most-recently-active task ordering in the UI: on the front page. Instead of inventing a whole new mechanism that's used for the same thing in one part of the UI but not the other, we should instead fix that mechanism. That merely resuming idle sessions bumps their transcript's mtime (and thus our activity timestamp) is an oversight. The cure is to improve that mechanism - check the logged timestamp of the last message signaling real activity instead of the directory entry's, memoized if necessary to avoid a |
Probably should make this a bit more future-proof with more structure, e.g.: - ui:
- sidebar:
- sort: activity # default being `creation` - which sorting by tid gives you naturally |
71e0a48 to
716a416
Compare
|
Rebased onto current master, and added a second commit: in recency mode the list now opens at its top, on load, on every reconnect, and when the sidebar is shown. The two points above are still open; the |
Adds ui.sidebar.sort, with values creation (the default, the tid order the list has always had) and activity. With activity, the most recently active task sits at the top and the order updates as tasks are used. A parent rises with its most recently active descendant at any depth, so a task does not sit stale above or below work happening beneath it. Siblings re-sort among themselves and nothing is reparented, leaving the tree structure untouched. Archive and Import move below the live tasks, and New Task moves to the top. The order uses last_active, the timestamp the welcome page and search already sort by. last_active is cleared on session start, for crash recovery, so a task that was alive when the backend stopped has it recovered at startup, until now from the transcript's mtime. The mtime is not activity: records written around a session's start and exit (claude's last-prompt and cost-state, for example) touch the file without any work happening, so a restart moved alive tasks to the restart time, on the welcome page as well as in this ordering. Recovery now takes the timestamp of the transcript's newest conversation record. Only the tail of the file is read, since transcripts reach tens of megabytes; the window grows if the tail holds no such record. Claude and Codex record shapes are recognized, and for a transcript with none of them recovery falls back to the mtime as before. The read covers the same tasks the mtime recovery did, those whose last_active was cleared, and the result is persisted as before.
.sidebar-list is column-reverse, so its resting scroll position is the visual bottom; in activity mode that is the least recently active end, the opposite of where the interesting tasks are. Scroll the top-most child into view when the sidebar opens: on mount, when the task list finishes loading (the first load, and every reconnect, which empties and refills the list without the sidebar ever hiding), when the sidebar becomes visible on mobile, and when activity ordering is switched on. Keyed on the load completing rather than on the list having content, since the list arrives in packets. It runs before the active-item effect, so deep-linking an off-screen task still scrolls to it.
716a416 to
5e32e4c
Compare
|
Reworked along both lines and force-pushed; the description is updated to match. There is no new timestamp any more. The ordering uses The option is now |
|
Hold on, this does three things then:
This should then be delivered in three commits. The first two need their own options. As for changing the sort direction, the newest-first assumption is quite embedded in the design. I assume this comes along with wanting tasks to be listed top-down, not bottom-up? So make the DOM order reversal optional and adjust the subtask subtree construction accordingly? |
| if (exists(jp)) | ||
| { | ||
| td.lastActive = timeLastModified(jp).stdTime; | ||
| td.lastActive = lastTurnStdTime(jp); |
There was a problem hiding this comment.
We might need to memoise this (on file size probably), startup performance may otherwise suffer greatly. Can we get a measurement of before/after startup time with 50 active tasks and a cold cache?
| /// StdTime of one transcript line, or 0 if it is not a conversation turn. | ||
| /// | ||
| /// Parsed by hand rather than by deserializing: this runs over every line of | ||
| /// every task's tail at startup, and the records carry large nested payloads | ||
| /// that would be built and thrown away. | ||
| /// | ||
| /// Only conversation records count, in the two shapes recognized: | ||
| /// claude: {"type":"user"|"assistant", ..., "timestamp":"..."} | ||
| /// codex: {"timestamp":"...", "type":"response_item", "payload":{...}} | ||
| /// Every other record type is skipped. | ||
| private long turnTimestamp(const(char)[] line) nothrow | ||
| { | ||
| import std.string : indexOf; | ||
|
|
||
| if (line.length == 0) | ||
| return 0; | ||
| if (line.indexOf(`"type":"user"`) < 0 | ||
| && line.indexOf(`"type":"assistant"`) < 0 | ||
| && line.indexOf(`"type":"response_item"`) < 0) | ||
| return 0; | ||
|
|
||
| auto key = line.indexOf(`"timestamp":"`); | ||
| if (key < 0) | ||
| return 0; | ||
| auto valueStart = key + `"timestamp":"`.length; | ||
| auto rest = line[valueStart .. $]; | ||
| auto close = rest.indexOf('"'); | ||
| if (close < 0) | ||
| return 0; | ||
|
|
||
| try | ||
| return SysTime.fromISOExtString(rest[0 .. close]).stdTime; | ||
| catch (Exception) | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
Please don't invent yet another place where we parse rollout files. This should be owned by the agent class.
| private long scanTail(string path, size_t window, out bool wholeFile) nothrow | ||
| { | ||
| import std.stdio : File; | ||
|
|
||
| wholeFile = false; | ||
| try | ||
| { | ||
| auto f = File(path, "rb"); | ||
| scope(exit) f.close(); | ||
| auto size = f.size(); | ||
| if (size == 0) | ||
| { | ||
| wholeFile = true; | ||
| return 0; | ||
| } | ||
|
|
||
| ulong start = size > window ? size - window : 0; | ||
| wholeFile = start == 0; | ||
| f.seek(start); | ||
| auto buf = new ubyte[cast(size_t)(size - start)]; | ||
| auto chunk = f.rawRead(buf); | ||
|
|
||
| auto text = cast(string) chunk.idup; | ||
| // a mid-file window almost certainly starts inside a record; that | ||
| // partial first line would fail to parse anyway, but dropping it keeps | ||
| // the intent explicit | ||
| if (!wholeFile) | ||
| { | ||
| import std.string : indexOf; | ||
| auto nl = text.indexOf('\n'); | ||
| text = nl < 0 ? "" : text[nl + 1 .. $]; | ||
| } | ||
|
|
||
| long newest = 0; | ||
| import std.algorithm : splitter; | ||
| foreach (line; text.splitter('\n')) | ||
| { | ||
| auto ts = turnTimestamp(line); | ||
| if (ts > newest) | ||
| newest = ts; | ||
| } | ||
| return newest; | ||
| } | ||
| catch (Exception) | ||
| return 0; | ||
| catch (Error) | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
And this should be a shared facility (but it sounds like you want to iterate over .jsonl lines in reverse order).
Adds
ui.sidebar.sort, with valuescreation(the default, the tid order the sidebar has always had) andactivity. Withactivity, the sidebar is ordered by activity: the most recently active task sits at the top and the order updates as tasks are used..sidebar-listis column-reverse and would otherwise rest at the least recently active end.The activity signal
The ordering uses
last_active, the timestamp the welcome page and search already sort by.last_activeis cleared on session start, for crash recovery, so a task that was alive when the backend stopped has it recovered at startup. That recovery used the transcript's mtime, which is not activity: records written around a session's start and exit (Claude'slast-promptandcost-state, for example) touch the file without any work happening, so a restart moved alive tasks to the restart time, on the welcome page as well.Recovery now takes the timestamp of the transcript's newest conversation record instead.
cydo.workflow.history.last_turnreads only the tail of the file, since transcripts reach tens of megabytes; the window grows if the tail holds no conversation record. Claude and Codex record shapes are recognized, and a transcript with neither falls back to the mtime as before. The read covers the same tasks the mtime recovery did, those whoselast_activewas cleared, and the result is persisted as before. There is no new column or migration.Default
With
sortunset orcreation, the creation-ordered list, the Archive and Import placement and the New Task position are exactly as before; the sort, the group placement, the New Task move and the scroll-to-top are each gated onactivity. Thelast_activerecovery change applies regardless, since it fixes the existing signal.Tests
Sidebar.test.tscovers both modes: unchanged order withcreation, activity order withactivity, a parent raised by a deep descendant, siblings re-sorting without reparenting, and the group placement in each mode.last_turn.dunit tests cover a transcript whose newest records are not conversation, a transcript with no conversation records, malformed lines, a missing file, the Codex shape, and a conversation record buried past the first scan window.